// (c) Copyright 2013-2014 Stephen Gold <sgold@sonic.net>
// Distributed under the terms of the GNU General Public License

/*
 This file is part of the Bats Game.

 The Bats Game is free software: you can redistribute it and/or modify
 it under the terms of the GNU General Public License as published by the
 Free Software Foundation, either version 3 of the License, or (at your
 option) any later version.

 The Bats Game is distributed in the hope that it will be useful, but
 WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
 or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 for more details.

 You should have received a copy of the GNU General Public License
 along with the Bats Game.  If not, see <http://www.gnu.org/licenses/>.
 */
package bats.map;

import bats.Assets;
import bats.SpatialProperties;
import bats.control.PickedControl;
import bats.indicator.Cursor3DIndicator;
import bats.scene.SceneNode;
import bats.ui.HeadsUpDisplay;
import com.jme3.bullet.collision.shapes.BoxCollisionShape;
import com.jme3.bullet.collision.shapes.CollisionShape;
import com.jme3.bullet.control.RigidBodyControl;
import com.jme3.bullet.util.CollisionShapeFactory;
import com.jme3.material.Material;
import com.jme3.math.FastMath;
import com.jme3.math.Quaternion;
import com.jme3.math.Vector3f;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.Geometry;
import com.jme3.scene.Mesh.Mode;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import java.io.PrintStream;
import java.util.logging.Level;
import java.util.logging.Logger;
import jme3utilities.MySpatial;
import jme3utilities.MyString;
import jme3utilities.NameGenerator;
import jme3utilities.math.VectorXZ;
import jme3utilities.xml.DomInput;
import jme3utilities.xml.XmlOutput;
import org.w3c.dom.Attr;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.NodeList;

/**
 * Manage the map blocks in a particular scene.
 *
 * @author Stephen Gold <sgold@sonic.net>
 */
public class Blocks {
    // *************************************************************************
    // constants

    /**
     * horizontal grid unit for blocks (in meters)
     */
    final private static float gridUnitXZ = 1.25f;
    /**
     * vertical grid unit for blocks (in meters)
     */
    final private static float gridUnitY = 2.5f;
    /**
     * message logger for this class
     */
    final private static Logger logger =
            Logger.getLogger(Blocks.class.getName());
    /**
     * items in the "add block tower" popup submenu.
     */
    final public static String addBlockTowerItems[] = {
        "pavement", "base", "entry", "battlement"
    };
    /**
     * pickType for blocks
     */
    final private static String pickType = "block";
    /**
     * tag for blocks in XML files
     */
    final public static String xmlTag = "block";
    // *************************************************************************
    // fields
    /**
     * scene in which these map blocks reside: set by constructor
     */
    final private SceneNode scene;
    // *************************************************************************
    // constructors

    /**
     * Instantiate for the specified scene.
     *
     * @param scene where the blocks will reside (not null)
     */
    public Blocks(SceneNode scene) {
        assert scene != null;
        this.scene = scene;
    }
    // *************************************************************************
    // new methods exposed

    /**
     * Add a pickable map block based on a DOM element.
     *
     * @param element which element (not null)
     */
    public void add(Element element) {
        /*
         * Get prefixType from the element's attributes.
         */
        NamedNodeMap attributes = element.getAttributes();
        Attr attr = (Attr) attributes.getNamedItem("type");
        if (attr == null) {
            logger.log(Level.SEVERE,
                    "missing attribute (type) in element (block)");
            return;
        }
        String blockType = attr.getValue();
        /*
         * Get the location and orientation from child elements.
         */
        Vector3f location = null;
        Quaternion orientation = null;
        NodeList children = element.getChildNodes();
        for (int childIndex = 0; childIndex < children.getLength();
                childIndex++) {
            org.w3c.dom.Node child = children.item(childIndex);

            if (child instanceof Element) {
                Element e = (Element) child;
                String n = e.getTagName();
                switch (n) {
                    case "location":
                        if (location != null) {
                            logger.log(Level.SEVERE, "multiple attribute "
                                    + "(location) in element (block)");
                            return;
                        }
                        float scaleFactor = scene.getScaleFactor();
                        location = DomInput.createVector3f(e, scaleFactor);
                        break;
                    case "orientation":
                        if (orientation != null) {
                            logger.log(Level.SEVERE, "multiple attribute "
                                    + "(orientation) in element (block)");
                            return;
                        }
                        orientation = DomInput.createQuaternion(e);
                        break;
                    default:
                        logger.log(Level.SEVERE, "unexpected attribute "
                                + "{0} in element (block)", MyString.quote(n));
                        return;
                }
            }
        }
        if (location == null) {
            assert false; // missing location for block
        }
        if (orientation == null) {
            assert false; // missing orientation for block
        }

        add(blockType, location, orientation);
    }

    /**
     * Duplicate a pickable map block. Duplicating differs from cloning because
     * the duplicate gets a unique name and any descendants of the duplicate get
     * renamed to match. Besides, cloning doesn't work well on physics controls.
     *
     * @param originalBlock which block to duplicate (not null)
     * @return the new block
     */
    public Node duplicate(Spatial originalBlock) {
        String name = originalBlock.getName();
        logger.log(Level.INFO, "duplicating block {0}", MyString.quote(name));

        String blockType = SpatialProperties.getPrefixType(originalBlock);
        Vector3f location = MySpatial.getWorldLocation(originalBlock);
        Quaternion orientation = MySpatial.getWorldOrientation(originalBlock);
        Node result = add(blockType, location, orientation);
        return result;
    }

    /**
     * Get the world coordinates for a block's pick indicator.
     *
     * @param spatial which block (not null)
     * @return a new vector
     */
    public static Vector3f getIndicatorLocation(Spatial spatial) {
        assert spatial != null;
        assert SpatialProperties.isBlock(spatial) : spatial;

        Vector3f base = MySpatial.getWorldLocation(spatial);
        float topY = MySpatial.getMaxY(spatial);
        Vector3f result = new Vector3f(base.x, topY, base.z);
        return result;
    }

    /**
     * Determine the block type from the name of the block's folder.
     *
     * @param blockFolder (not null)
     * @return name of block type
     */
    public static String getType(String blockFolder) {
        /*
         * Split the path into components.
         */
        String[] components = blockFolder.split("/");
        /*
         * The type is the final component of the path.
         */
        int length = components.length;
        assert length > 0 : length;
        String result = components[length - 1];

        return result;
    }

    /**
     * Move (translate) a block in its own local coordinate system.
     *
     * @param spatial which block to move (not null)
     * @param forward forward translation (in grid units)
     * @param right translation to the right (in grid units)
     * @param up upward translation (in grid units)
     */
    public void moveLocal(Spatial spatial, float forward, float right,
            float up) {
        Quaternion orientation = MySpatial.getWorldOrientation(spatial);
        Vector3f localOffset = new Vector3f(forward, up, right);
        Vector3f gridOffset = orientation.mult(localOffset);
        moveGrid(spatial, gridOffset);
    }

    /**
     * Move (translate) a block relative to the POV.
     *
     * @param spatial which block to move (not null)
     * @param up translation toward the top of the view (in grid units)
     * @param right translation toward the right of the view (in grid units)
     */
    public void moveView(Spatial spatial, float up, float right) {
        assert spatial != null;

        VectorXZ viewDirection = new VectorXZ(-right, up);
        viewDirection = viewDirection.normalize();
        Vector3f worldLeft = scene.getApplication().getCamera().getLeft();
        VectorXZ left = new VectorXZ(worldLeft);
        VectorXZ moveDirection = left.rotate(viewDirection);
        moveDirection = moveDirection.cardinalize();
        Vector3f gridOffset = moveDirection.toVector3f();
        moveGrid(spatial, gridOffset);
    }

    /**
     * Process a hotkey or button action which has just begun.
     *
     * @param words action string split into an array of words (not null)
     * @return true if the action was handled, false if it wasn't handled
     */
    public boolean processAction(String[] words) {
        String verb = words[0];
        if ("add".equals(verb)) {
            assert words.length > 1;
            String what = words[1];
            if ("block".equals(what) && words.length > 2) {
                String folder = words[2];
                if (words.length == 3) {
                    if ("tower".equals(folder)) {
                        HeadsUpDisplay hud = HeadsUpDisplay.getCurrent();
                        hud.showPopup(words, addBlockTowerItems);
                        return true;
                    }
                } else if (words.length == 4) {
                    folder = String.format("%s/%s", folder, words[3]);
                }
                /*
                 * Add a map block at the insertion point.
                 */
                addAsPick(folder);
                return true;
            }
        }

        return false;
    }

    /**
     * Reload a map block and unpick it.
     *
     * @param originalBlock which block to reload (not null)
     */
    public void reload(Spatial originalBlock) {
        String name = originalBlock.getName();
        logger.log(Level.INFO, "reloading block {0}", MyString.quote(name));

        Node reloaded = duplicate(originalBlock);
        scene.deleteSpatial(originalBlock);
        reloaded.setName(name);
    }

    /**
     * Lock or unlock all doors of a map block.
     *
     * @param node which map block (not null)
     * @param newState if true, lock all doors; if false, unlock all doors
     */
    public void setLocked(Node node, boolean newState) {
        for (Spatial part : node.getChildren()) {
            DoorControl doorControl = part.getControl(DoorControl.class);
            if (doorControl != null) {
                doorControl.setLocked(newState);
            }
        }
    }

    /**
     * Serialize a map block to an XML stream.
     *
     * @param stream (not null)
     * @param spatial which block to serialize (not null)
     * @param indent whitespace at the start of each line (not null)
     */
    public void writeXml(PrintStream stream, Spatial spatial,
            String indent) {
        assert spatial != null;
        assert indent != null;

        String prefixType = SpatialProperties.getPrefixType(spatial);
        stream.printf("%s<%s type=%s>%n", indent, xmlTag,
                MyString.quote(prefixType));

        Vector3f location = MySpatial.getWorldLocation(spatial);
        String indentMore = indent + "    ";
        float scaleFactor = scene.getScaleFactor();
        XmlOutput.put(stream, "location", location, indentMore, scaleFactor);

        Quaternion orientation = MySpatial.getWorldOrientation(spatial);
        float w = orientation.getW();
        if (w == 0f) {
            orientation.normalizeLocal();
        } else {
            orientation.multLocal(1f / w);
        }
        XmlOutput.put(stream, "orientation", orientation, indentMore);

        stream.printf("%s</%s>%n", indent, xmlTag);
    }
    // *************************************************************************
    // private methods

    /**
     * Add a map block at the insertion point.
     *
     * @param blockFolder which kind of block to add (not null)
     * @return a new parented node which represents the block
     */
    private Node add(String blockFolder) {
        assert blockFolder != null;

        float scaleFactor = scene.getScaleFactor();
        float gridXZ = gridUnitXZ / scaleFactor; // in world units
        float gridY = gridUnitY / scaleFactor; // in world units
        Cursor3DIndicator cursor3d = scene.getIndicators().getCursor3D();
        Vector3f location = cursor3d.getWorldLocation();
        /*
         * Discretize the cursor location to a grid intersection.
         */
        float x = gridXZ * FastMath.floor(0.5f + location.x / gridXZ);
        float y = gridY * FastMath.floor(0.5f + location.y / gridY);
        float z = gridXZ * FastMath.floor(0.5f + location.z / gridXZ);
        location = new Vector3f(x, y, z);

        Node result = add(blockFolder, location);
        return result;
    }

    /**
     * Add a pickable map block with default orientation at the specified
     * location.
     *
     * @param blockFolder which kind of block to add (not null)
     * @param location world coordinates for the block (not null)
     * @return a new parented node which represents the block
     */
    private Node add(String blockFolder, Vector3f location) {
        assert blockFolder != null;
        assert location != null;

        Node node = create(blockFolder);
        /*
         * Make the block pickable.
         */
        PickedControl pickedControl = new PickedControl();
        node.addControl(pickedControl);
        SpatialProperties.setPickType(node, pickType);
        scene.getWorld().getPickables().add(node);
        /*
         * Position the block.
         */
        MySpatial.moveWorld(node, location);

        assert !MySpatial.isOrphan(node);
        return node;
    }

    /**
     * Add a pickable map block with the specified location and orientation.
     *
     * @param blockFolder which kind of block to add (not null)
     * @param location (not null)
     * @param orientation (not null)
     * @return a new parented node which represents the block
     */
    private Node add(String blockFolder, Vector3f location,
            Quaternion orientation) {
        assert blockFolder != null;
        assert location != null;
        assert orientation != null;

        Node node = add(blockFolder, location);
        /*
         * Orient the block.
         */
        MySpatial.rotateObject(node, location, orientation);

        assert !MySpatial.isOrphan(node);
        return node;
    }

    /**
     * Add a map block at the insertion point as the only picked block.
     *
     * @param blockFolder which kind of block to add (not null)
     */
    private void addAsPick(String blockFolder) {
        assert blockFolder != null;
        /*
         * The new block is to be the ONLY picked block, so unpick the rest.
         */
        scene.getWorld().getPickables().clearPick();
        /*
         * Add the block to the map.
         */
        Node node = add(blockFolder);
        /*
         * Pick the block.
         */
        SpatialProperties.setPicked(node, true);
    }

    /**
     * Texture a fixed geometry of a block, give it a rigid body control, and
     * set its hardness and shadow mode.
     *
     * @param blockFolder (not null)
     * @param geometry which subpart to texture (not null, not orphan)
     */
    private void addFixed(String blockFolder, Geometry geometry) {
        assert blockFolder != null;
        assert geometry != null;
        assert !MySpatial.isOrphan(geometry) : geometry;

        String partName = geometry.getName();
        Mode mode = geometry.getMesh().getMode();
        if (mode != Mode.Triangles) {
            logger.log(Level.WARNING, "ignored geometry {0} with mesh mode {1}",
                    new Object[]{
                MyString.quote(geometry.getName()),
                mode
            });
            boolean success = geometry.removeFromParent();
            assert success;
            return;
        }
        String materialName = material(blockFolder, partName);
        if ("water".equals(materialName)) {
            geometry.setShadowMode(RenderQueue.ShadowMode.Off);
            /*
             * Adopt the geometry as a water feature.
             */
            scene.getWater().adoptAsFeature(geometry);
            return;
        }
        /*
         * Apply a block material to the part's geometry.
         */
        Material material = Assets.loadBlockMaterial(materialName);
        geometry.setMaterial(material);
        /*
         * Give the part a rigid body control.
         */
        CollisionShape shape = CollisionShapeFactory.createMeshShape(geometry);
        Vector3f scale = geometry.getWorldScale();
        shape.setScale(scale);
        float mass = 0f; // immobile
        RigidBodyControl rigidBodyControl = new RigidBodyControl(shape, mass);
        geometry.addControl(rigidBodyControl);
        rigidBodyControl.setFriction(MapNode.friction);
        scene.getWorld().getPhysics().addObject(rigidBodyControl);
        /*
         * Set the other properties of the part.
         */
        SpatialProperties.setHardness(geometry, materialName);
        geometry.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
    }

    /**
     * Add a movable part (such as a door) to a block.
     *
     * @param movableSpatial which part to add (not null, not orphan)
     * @param fixedSpatial a fixed part of the same block (not null, not orphan)
     */
    private void addMovable(Spatial movableSpatial, Spatial fixedSpatial) {
        assert movableSpatial != null;
        assert fixedSpatial != null;
        assert !MySpatial.isOrphan(movableSpatial) : movableSpatial;
        assert !MySpatial.isOrphan(fixedSpatial) : fixedSpatial;
        /*
         * Look up the properties of this part.
         */
        boolean locked, swingsClockwise;
        Vector3f pivot;
        String partName = movableSpatial.getName();
        switch (partName) {
            case "door a":
                /*
                 * The corners of the gateway passage are at x = -1.03333
                 * and x = 1.76667 with z = +/- 0.85.  TODO
                 *
                 * The doors pivot around a point offset 5 cm on x and z.
                 */
                locked = true;
                pivot = new Vector3f(1.81667f, 1.05f, -0.8f);
                swingsClockwise = false;
                break;
            case "door b":
                locked = true;
                pivot = new Vector3f(1.81667f, 1.05f, 0.8f);
                swingsClockwise = true;
                break;
            case "door c":
                locked = false;
                swingsClockwise = true;
                pivot = new Vector3f(-1.08333f, 1.05f, -0.8f);
                break;
            case "door d":
                locked = false;
                pivot = new Vector3f(-1.08333f, 1.05f, 0.8f);
                swingsClockwise = false;
                break;
            default:
                logger.log(Level.SEVERE, "unknown part {0}",
                        MyString.quote(partName));
                return;
        }
        /*
         * Texture the part's geometry.
         */
        Material material = Assets.loadBlockMaterial("wood");
        movableSpatial.setMaterial(material);
        /*
         * Create a box collision shape for the door.  The collision shape is
         * slightly smaller than the geometry so it will swing easily.
         */
        float halfThickness = 0.02f; // meters (instead of 0.05)
        float halfHeight = 1.04f; // meters (instead of 1.05)
        float halfWidth = 0.325f; // meters (instead of 0.425)
        Vector3f halfExtents = new Vector3f(halfThickness, halfHeight,
                halfWidth);
        float scaleFactor = scene.getScaleFactor();
        halfExtents.divideLocal(scaleFactor);
        CollisionShape shape = new BoxCollisionShape(halfExtents);
        /*
         * Add a physics control.
         */
        float mass = 50f; // kilograms
        float lowLimit, highLimit;
        if (swingsClockwise) {
            lowLimit = -1.5f; // radians
            highLimit = 3f; // radians
        } else { // swings counter-clockwise
            lowLimit = -3f; // radians
            highLimit = 1.5f; // radians
        }
        DoorControl doorControl = new DoorControl(shape, mass, scene,
                lowLimit, highLimit);
        scene.getWorld().getPhysics().addObject(doorControl);
        movableSpatial.addControl(doorControl);
        /*
         * Mount the door on a new hinge.
         */
        Vector3f axis = Vector3f.UNIT_Y;
        doorControl.mount(axis, pivot, fixedSpatial);
        /*
         * Lock or unlock the door.
         */
        doorControl.setLocked(locked);
        /*
         * Set other properties of the part.
         */
        SpatialProperties.setHardness(movableSpatial, "wood");
        RenderQueue.ShadowMode shadowMode =
                RenderQueue.ShadowMode.CastAndReceive;
        movableSpatial.setShadowMode(shadowMode);
    }

    /**
     * Create a block without adding it to the scene.
     *
     * @param blockFolder which kind of block to add (not null)
     * @return a new orphan node which represents the block
     */
    private Node create(String blockFolder) {
        assert blockFolder != null;
        /*
         * Load the block model.
         */
        Node node = loadModel(blockFolder);
        float scaleFactor = scene.getScaleFactor();
        node.setLocalScale(1f / scaleFactor);
        /*
         * By convention, the name of the model's fixed part is identical
         * to the block type.
         */
        String blockType = getType(blockFolder);
        Node fixedNode = (Node) node.getChild(blockType);
        if (fixedNode == null) {
            logger.log(Level.SEVERE,
                    "Invalid model for block {0}: no geometry named {1}.",
                    new Object[]{
                MyString.quote(blockFolder),
                MyString.quote(blockType)
            });
            return null;
        }
        /*
         * Each geometry in the fixed part corresponds to a material
         * slot in the Blender model.
         * Texture it, give it a physics control, and set its hardness.
         */
        Geometry fixedGeometry = null;
        for (Spatial part : fixedNode.getChildren()) {
            if (!(part instanceof Geometry)) {
                logger.log(Level.SEVERE, "Invalid block model: "
                        + "sub-part {0} is not a geometry.",
                        MyString.quote(part.getName()));
                return null;
            }
            Geometry geometry = (Geometry) part;
            addFixed(blockFolder, geometry);
            if (MySpatial.isPhysical(geometry)) {
                fixedGeometry = geometry;
            }
        }
        /*
         * Add movable parts (if any) to the block.
         */
        for (Spatial part : node.getChildren()) {
            if (part != fixedNode) {
                addMovable(part, fixedGeometry);
            }
        }

        assert MySpatial.isOrphan(node);
        return node;
    }

    /**
     * Load a block model without adding it to the scene.
     *
     * @param blockFolder which kind of block to load (not null)
     */
    private Node loadModel(String blockFolder) {
        assert blockFolder != null;
        /*
         * Load the block model.
         */
        Node node = Assets.loadBlockModel(blockFolder);
        /*
         * Generate a unique name for the new block.
         */
        NameGenerator nameGenerator = scene.getNameGenerator();
        String name = nameGenerator.unique(blockFolder);
        node.setName(name);
        return node;
    }

    /**
     * Determine the correct material for a named block part.
     *
     * @param blockFolder (not null)
     * @param partName the name of the block part (not null)
     * @return name of the material (or null if unknown part)
     */
    private static String material(String blockFolder, String partName) {
        assert blockFolder != null;
        assert partName != null;

        switch (partName) {
            case "ladder1":
                return "bronze";

            case "corridorPavement2":
            case "ground1":
            case "ground2x21":
            case "ground4x41":
            case "plain1":
            case "plain8x81":
            case "pond1":
                return "dirt";

            case "corridorPavement1":
            case "pavement2":
                return "pavers";

            case "arcade1":
            case "base2":
            case "battlement1":
            case "corridor1":
            case "entry2":
            case "gateway2":
            case "merlonLeft1":
            case "merlonRight1":
            case "parapet1":
            case "stairwayLeft1":
            case "stairwayRight1":
                return "stone";

            case "pool1":
                return "tile";

            case "pond2":
            case "pool2":
                return "water";

            case "arcade2":
            case "base1":
            case "corridor2":
            case "entry1":
            case "gateway1":
            case "ladder2":
            case "platform1":
                return "wood";

            case "pavement1":
                switch (blockFolder) {
                    case "tower/pavement":
                        return "dirt";
                    case "pavement":
                        return "pavers";
                }
        }
        logger.log(Level.SEVERE,
                "Unexpected block part: partName={0}, blockFolder={1}",
                new Object[]{
            MyString.quote(partName),
            MyString.quote(blockFolder)
        });
        assert false;
        return null;
    }

    /**
     * Move (translate) a block in the grid coordinate system.
     *
     * @param spatial which block to move (not null)
     * @param gridOffset world translation (in grid units, not null)
     */
    private void moveGrid(Spatial spatial, Vector3f gridOffset) {
        float scaleFactor = scene.getScaleFactor();
        float gridXZ = gridUnitXZ / scaleFactor; // in world units
        float gridY = gridUnitY / scaleFactor; // in world units
        Vector3f gridToWorld = new Vector3f(gridXZ, gridY, gridXZ);
        Vector3f worldOffset = gridOffset.mult(gridToWorld);
        MySpatial.moveWorld(spatial, worldOffset);
    }
}